package io.github.jkindwall.tabledriver;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;

/**
 * Class rerpresenting a table html element and all its contents
 */
public class Table
{
    private static final class FindHeaderResults
    {
        public final WebElement headerRow;
        public final int skipRows;

        public FindHeaderResults(WebElement headerRow, int skipRows)
        {
            this.headerRow = headerRow;
            this.skipRows = skipRows;
        }
    }

    private static final String INVALID_COLUMN_HEADER_EXCEPTION_TEMPLATE =
        "Argument: columnHeaderText.  The table does not contain a column with the header caption of '%s'.";
    private static final String FIND_CELL_XPATH_TEMPLATE = "(%s)/*[self::th or self::td][%d]";

    /*
        因为表头是固定不变的，修饰符为final
     */
    private final Map<String, Integer> headers;
    /*
        指定跳过的行数
     */
    private final int skipRows;
    /*
        xpath表达式： 跳过指定行数后的表格数据内容行元素集
     */
    private final String rowXPathPrefix;

    /*
     * 表格<table>元素
     */
    private final WebElement tableElement;
    /**
     * Gets the WebElement representing the root "table" element
     *
     * @return  The table WebElement
     */
    public WebElement getTableElement()
    {
        return this.tableElement;
    }

    /*
        表头元素
     */
    private final WebElement headerRow;
    /**
     * Gets the WebElement representing the header row (if any) of the table (the "tr" element that contains the column
     * header captions)
     *
     * @return  The header row WebElement
     */
    public WebElement getHeaderRow()
    {
        return this.headerRow;
    }

    private List<WebElement> headerElements;
    /**
     * Gets the collection of WebElements representing header elements not contained within the main table element
     *
     * @return  Collection of header WebElements
     */
    public List<WebElement> getHeaderElements()
    {
        return this.headerElements;
    }

    private static FindHeaderResults tryFindHeaderRow(WebElement element)
    {
        //xpath表达式首字母不带斜杠表示获取该节点的所有子元素节点
        List<WebElement> theadRows = element.findElements(By.xpath("thead/tr"));
        if (!theadRows.isEmpty())
        {
            return new FindHeaderResults(theadRows.get(theadRows.size() - 1), 0);
        }

        //xpath表达式首字母不带斜杠表示获取该节点的所有子元素节点
        List<WebElement> thRows = element.findElements(By.xpath("tbody/tr[th and not(td)] | tr[th and not(td)]"));
        if (!thRows.isEmpty())
        {
            return new FindHeaderResults(thRows.get(thRows.size() - 1), thRows.size());
        }

        //xpath表达式首字母不带斜杠表示获取该节点的所有子元素节点
        return new FindHeaderResults(element.findElement(By.xpath("tbody/tr[1] | tr[1]")), 1);
    }

    /*
        构造查找页面元素集的xpath路径表达式, 跳过指定tr元素行数
     */
    private static String buildRowXPathPrefix(WebElement element, int skipRows)
    {
        StringBuilder builder = new StringBuilder();
        if (!element.findElements(By.xpath("tbody")).isEmpty())
        {
            //xpath表达式不带斜杠表示获取所有其子元素节点
            builder.append("tbody/");
        }

        if (skipRows > 0)
        {
            //following-sibling - 选取当前节点之后的所有"同级"节点
            //tr[%d]/following-sibling::tr - 表示第%d个tr节点之后的同级tr节点(兄弟节点),即跳过skipRows指定的行数
            builder.append(String.format("tr[%d]/following-sibling::tr", skipRows));
        }
        else
        {
            builder.append("tr");
        }

        return builder.toString();
    }

    /*
        获取表头
     */
    private Map<String, Integer> loadHeaders()
    {
        //提取表头由<th>或<td>标签组成的元素。通过在路径表达式中使用竖"|"运算符，您可以组合(或关系)选取结果
        List<WebElement> localHeaderElements = this.headerRow != null
            ? this.headerRow.findElements(By.xpath("th | td"))
            : this.headerElements;
        //localHeaders: 元素内容为列标题和索引的映射(th或td都算一列)
        Map<String, Integer> localHeaders = new HashMap<>();
        //含表头
        if (localHeaderElements != null)
        {
            int columnIndex = 0;
            for (WebElement headerElement : localHeaderElements)
            {
                String headerText = headerElement.getText();
                int headerSuffix = 0;
                //Map中键不能重复。如果header文本值重复，则加数字递增后缀'-%d'
                while (localHeaders.containsKey(headerText))
                {
                    headerText = String.format("%s-%s", headerElement.getText(), ++headerSuffix);
                }
                //标题和索引(从0开始)，以索引的字符串为键和索引为值来构造虚拟标题
                localHeaders.put(headerText, columnIndex++);
            }
        }
        else //无表头时，标题用索引的字符串类型表示
        {   //tbody/tr[1]/*[self::th or self::td]
            List<WebElement> columns = this.tableElement.findElements(
                By.xpath( //例如：tbody/tr[1]/*[self::th or self::td]
                        String.format("%s[1]/*[self::th or self::td]", this.rowXPathPrefix)) //取出表格数据内容中的第一行，然后获取包含th或td的列
            ); //this.rowXPathPrefix以tbody/开头，xpath表达式不带斜杠表示获取所有其子元素节点
            for (int i = 0; i < columns.size(); i++)
            {
                //将当前索引i的值转为字符串
                String indexText = String.valueOf(i);
                localHeaders.put(indexText, i);
            }
        }

        return localHeaders;
    }

    /**
     * Creates a new instance of the Table class based on the specified "table" element
     *
     * The Table class will attempt to infer the structure of the table based on a few standard table layouts.  If this
     * does not work, try using one of the alternative Create methods that allow you to specify how to handle table
     * headers.
     *
     * @param element   WebElement representing the "table" element
     * @return          The new Table instance
     */
    public static Table create(WebElement element)
    {
        FindHeaderResults findHeaderResults = Table.tryFindHeaderRow(element);
        return new Table(element, findHeaderResults.headerRow, null, findHeaderResults.skipRows);
    }

    /**
     * Creates a new instance of the Table class based on the specified "table" element
     *
     * @param element           WebElement representing the "table" element（the tag is <table>）
     * @param headerRowElement  WebElement representing the header row of the table
     * @param skipRows          The number of rows at the top of the table body that do not represent the table content
     *                          (usually because they contain a title row or column headers)
     * @return                  The new Table instance
     */
    public static Table createWithHeaderRow(WebElement element, WebElement headerRowElement, int skipRows)
    {
        return new Table(element, headerRowElement, null, skipRows);
    }

    /**
     * Creates a new instance of the Table class based on the specified "table" element, where the column headers are
     * located outside the table tag
     *
     * Use this Create method for situatoins where the table headers are contained in a separate table element or some
     * other type of web element container external to the main table element.
     *
     * @param element           WebElement representing the "table" element
     * @param headerElements    Collection of WebElements representing column headers that are not contained within the
     *                          specified table element
     * @param skipRows          The number of rows at the top of the table body that do not represent the table content
     *                          (usually because they contain a title row or column headers)
     * @return                  The new Table instance
     */
    public static Table createWithExternalHeaders(WebElement element, List<WebElement> headerElements, int skipRows)
    {
        return new Table(element, null, headerElements, skipRows);
    }

    /**
     * 单例模式
     * Creates a new instance of the Table class based on the specified "table" element, with no displayed column headers
     *
     * Table instances created with this method will only allow specifying columns by their numeric index.、
     * 该方法创建的表格只允许通过数字索引的方式指定列
     * @param tableElement   WebElement representing the "table" element
     * @param skipRows  The number of rows at the top of the table body that do not represent the table content (usually
     *                  because they contain a title row or column headers)
     * @return          The new Table instance
     */
    public static Table createWithNoHeaders(WebElement tableElement, int skipRows)
    {
        //因为没有表头所以传null
        return new Table(tableElement, null, null, skipRows);
    }

    /**
     * 表格构造器
     * @param tableElement
     * @param headerRowElement
     * @param headerElements
     * @param skipRows
     */
    private Table(WebElement tableElement, WebElement headerRowElement, List<WebElement> headerElements, int skipRows)
    {
        if (!"TABLE".equalsIgnoreCase(tableElement.getTagName()))
        {
            throw new IllegalArgumentException("Argument: element.  Must specify a <table> element.");
        }

        if (headerRowElement != null && !"TR".equalsIgnoreCase(headerRowElement.getTagName()))
        {
            throw new IllegalArgumentException("Argument: headerRowElement. Must specify a <tr> element.");
        }

        this.tableElement = tableElement;
        this.headerRow = headerRowElement;
        this.headerElements = headerElements;
        this.skipRows = skipRows;
        //构造页面元素集(表格中所有数据行内容)的xpath路径表达式, 并且跳过指定tr元素行数(skipRows)
        this.rowXPathPrefix = Table.buildRowXPathPrefix(tableElement, skipRows);
        //表头标题和索引位置映射关系(元素个数为列的个数),其中在一行中每包含td或th标签的都算一列
        this.headers = Collections.unmodifiableMap(this.loadHeaders());
    }

    /**
     * Gets all the content rows of the table
     * 获取所有表内容的行元素集
     * @return  Collection of TableRow objects representing all rows in the table
     */
    public List<TableRow> getRows()
    {
        List<TableRow> tableRows = this.tableElement
            //根据xpath查找到所有表格内容元素(列表)
            .findElements(By.xpath(this.rowXPathPrefix))
            .stream()
            .map(e -> new TableRow(e, this.headers, this.skipRows))
            .collect(Collectors.toList());
        //unmodifiableList为不可修改(只读)集合
        return Collections.unmodifiableList(tableRows);
    }

    /**
     * Gets the first row in the table that matches the specified rowQuery string
     *
     * @param rowQuery  A RowQuery string identifying one or more rows by their contents
     * @return          TableRow representing the first row that matches the specified rowQuery string
     */
    public TableRow findRow(String rowQuery)
    {
        String rowXPath = this.buildXPath(rowQuery);
        WebElement rowElement = this.tableElement.findElement(By.xpath(rowXPath));
        return new TableRow(rowElement, this.headers, this.skipRows);
    }
    /**
     * Gets the specified row in the table
     *
     * @param rowIndex  Index of the row
     * @return          TableRow representing the specified row in the table
     */
    public TableRow findRow(int rowIndex)
    {
        String rowXPath = this.buildXPath(rowIndex);
        WebElement rowElement = this.tableElement.findElement(By.xpath(rowXPath));
        return new TableRow(rowElement, this.headers, this.skipRows);
    }

    /**
     * Gets all rows in the table that matches the specified rowQuery string
     *
     * @param rowQuery  A RowQuery string identifying one or more rows by their contents
     * @return          Collection of TableRows representing all rows that match the specified rowQuery string
     */
    public List<TableRow> findRows(String rowQuery)
    {
        String rowXPath = this.buildXPath(rowQuery);
        List<TableRow> tableRows = this.tableElement
            .findElements(By.xpath(rowXPath))
            .stream()
            .map(e -> new TableRow(e, this.headers, this.skipRows))
            .collect(Collectors.toList());
        return Collections.unmodifiableList(tableRows);
    }

    /**
     * Gets the cell in the specified column from the first row that matches the specified rowQuery string
     *
     * @param rowQuery          A RowQuery string identifying one or more rows by their contents
     * @param columnHeaderText  Identifies the column under which to find cells by the column's header text
     * @return                  TableCell representing the cell under the specified column from the specified row
     */
    public TableCell findCell(String rowQuery, String columnHeaderText)
    {
        if (!this.headers.containsKey(columnHeaderText))
        {
            throw new IllegalArgumentException(String.format(
                Table.INVALID_COLUMN_HEADER_EXCEPTION_TEMPLATE,
                columnHeaderText));
        }

        return this.findCell(rowQuery, this.headers.get(columnHeaderText));
    }

    /**
     * Gets the cell in the specified column from the first row that matches the specified rowQuery string
     *
     * @param rowQuery      A RowQuery string identifying one or more rows by their contents
     * @param columnIndex   Identifies the column under which to find cells by the column's index
     * @return              TableCell representing the cell under the specified column from the specified row
     */
    public TableCell findCell(String rowQuery, int columnIndex)
    {
        String rowXPath = this.buildXPath(rowQuery);
        //这里列索引从0开始，但是xpath中，索引下标从1开始(所以+1)
        int xpathCellIndex = columnIndex + 1;
        String cellXPath = String.format(Table.FIND_CELL_XPATH_TEMPLATE, rowXPath, xpathCellIndex);
        WebElement cellElement = this.tableElement.findElement(By.xpath(cellXPath));
        return new TableCell(cellElement, columnIndex, this.skipRows);
    }

    /**
     * Gets the cell in the specified column from the specified row
     *
     * @param rowIndex          Index of the row
     * @param columnHeaderText  Identifies the column under which to find cells by the column's header text
     * @return                  TableCell representing the cell under the specified column from the specified row
     */
    public TableCell findCell(int rowIndex, String columnHeaderText)
    {
        if (!this.headers.containsKey(columnHeaderText))
        {
            throw new IllegalArgumentException(String.format(
                Table.INVALID_COLUMN_HEADER_EXCEPTION_TEMPLATE,
                columnHeaderText));
        }

        return this.findCell(rowIndex, this.headers.get(columnHeaderText));
    }

    /**
     * Gets the cell in the specified column from the specified row
     *
     * @param rowIndex      Index of the row
     * @param columnIndex   Identifies the column under which to find cells by the column's index
     * @return              TableCell representing the cell under the specified column from the specified row
     */
    public TableCell findCell(int rowIndex, int columnIndex)
    {
        String rowXPath = this.buildXPath(rowIndex);
        int xpathCellIndex = columnIndex + 1;
        String cellXPath = String.format(Table.FIND_CELL_XPATH_TEMPLATE, rowXPath, xpathCellIndex);
        WebElement cellElement = this.tableElement.findElement(By.xpath(cellXPath));
        return new TableCell(cellElement, columnIndex, this.skipRows);
    }

    /**
     * Gets the cells in the specified column from the all rows that match the specified rowQuery string
     *
     * @param rowQuery          A RowQuery string identifying one or more rows by their contents
     * @param columnHeaderText  Identifies the column under which to find cells by the column's header text
     * @return                  Collection of TableCells representing the cells under the specified column from the
     *                          specified rows
     */
    public List<TableCell> findCells(String rowQuery, String columnHeaderText)
    {
        if (!this.headers.containsKey(columnHeaderText))
        {
            throw new IllegalArgumentException(String.format(
                Table.INVALID_COLUMN_HEADER_EXCEPTION_TEMPLATE,
                columnHeaderText));
        }

        return this.findCells(rowQuery, this.headers.get(columnHeaderText));
    }

    /// <summary>
    /// Gets the cells in the specified column from the all rows that match the specified rowQuery string
    /// </summary>
    /// <param name="rowQuery">A RowQuery string identifying one or more rows by their contents</param>
    /// <param name="columnIndex">Identifies the column under which to find cells by the column's index</param>
    /// <returns>Collection of TableCells representing the cells under the specified column from the specified rows</returns>
    /**
     * Gets the cells in the specified column from the all rows that match the specified rowQuery string
     *
     * @param rowQuery      A RowQuery string identifying one or more rows by their contents
     * @param columnIndex   Identifies the column under which to find cells by the column's index
     * @return              Collection of TableCells representing the cells under the specified column from the specified
     *                      rows
     */
    public List<TableCell> findCells(String rowQuery, int columnIndex)
    {
        String rowXPath = this.buildXPath(rowQuery);
        int xpathCellIndex = columnIndex + 1;
        String cellXPath = String.format(Table.FIND_CELL_XPATH_TEMPLATE, rowXPath, xpathCellIndex);
        List<WebElement> tableCellElements = this.tableElement
            .findElements(By.xpath(cellXPath));
        List<TableCell> tableCells = IntStream.range(0, tableCellElements.size())
            .mapToObj(i -> new TableCell(tableCellElements.get(i), i, this.skipRows))
            .collect(Collectors.toList());
        return Collections.unmodifiableList(tableCells);
    }

    private String buildXPath(int rowIndex)
    {
        int xpathRowIndex = rowIndex + 1;
        return String.format("%s[%d]", this.rowXPathPrefix, xpathRowIndex);
    }

    private String buildXPath(String rowQuery)
    {
        return this.buildXPath(OrGroup.parse(rowQuery));
    }

    private String buildXPath(OrGroup orGroup)
    {
        return String.join(
            " | ",
            orGroup.getAndGroups()
                .stream()
                .map(this::buildXPath)
                .toArray(String[]::new));
    }

    private String buildXPath(AndGroup andGroup)
    {
        List<String> predicates = andGroup.getConditions()
            .stream()
            .map(this::buildXPathPredicate)
            .collect(Collectors.toList());
        String[] parts = Stream.concat(Stream.of(rowXPathPrefix), predicates.stream()).toArray(String[]::new);
        return String.join("", parts);
    }

    private String buildXPathPredicate(FieldCondition fieldCondition)
    {
        int xpathHeaderIndex = 0;
        if (fieldCondition.isFieldByIndex())
        {
            xpathHeaderIndex = fieldCondition.getFieldIndex().get() + 1;
        }
        else if (this.headers.containsKey(fieldCondition.getField()))
        {
            xpathHeaderIndex = this.headers.get(fieldCondition.getField()) + 1;
        }
        else
        {
            throw new IllegalArgumentException(String.format(
                "Argument: fieldCondition.  The table does not contain a column with the header caption of '%s'.",
                fieldCondition.getField()));
        }

        if (fieldCondition.getOperation().equals(FieldOperation.STARTS_WITH))
        {
            return String.format(
                "[starts-with(*[self::th or self::td][%d],\"%s\")]",
                xpathHeaderIndex,
                fieldCondition.getValue());
        }
        else if (fieldCondition.getOperation().equals(FieldOperation.CONTAINS))
        {
            return String.format(
                "[contains(*[self::th or self::td][%d],\"%s\")]",
                xpathHeaderIndex,
                fieldCondition.getValue());
        }
        else
        {
            return String.format(
                "[*[self::th or self::td][%d]%s\"%s\"]",
                xpathHeaderIndex,
                fieldCondition.getOperation(),
                fieldCondition.getValue());
        }
    }

    /**
     * Gets the number of columns in the table
     *
     * @return  The number of columns in the table
     */
    public int getColumnCount()
    {
        return this.headers.size();
    }

    /**
     * Gets the number of content rows in the table
     *
     * @return  The number of content rows in the table
     */
    public int getRowCount()
    {
        return this.tableElement.findElements(By.xpath(this.rowXPathPrefix)).size();
    }

    /**
     * Gets the specified column header element
     *
     * @param headerText    Identifies the header to get by the header's text
     * @return              WebElement representing the specified column header
     */
    public WebElement getHeader(String headerText)
    {
        if (this.headerRow == null && this.headerElements == null)
        {
            throw new UnsupportedOperationException("This table does not have identified column headers.");
        }

        if (!this.headers.containsKey(headerText))
        {
            throw new IllegalArgumentException(String.format(
                "Argument: headerText.  The table does not contain a column with the header caption of '%s'.",
                headerText));
        }

        return this.getHeader(this.headers.get(headerText));
    }

    /**
     * Get sthe specified column header element
     *
     * @param headerIndex   Identifies the header to get by the header's index
     * @return              WebElement representing the specified column header
     */
    public WebElement getHeader(int headerIndex)
    {
        if (this.headerRow != null)
        {
            int xpathIndex = headerIndex + 1;
            return this.headerRow.findElement(By.xpath(String.format("(td | th)[%d]", xpathIndex)));
        }
        else if (this.headerElements != null)
        {
            return this.headerElements.get(headerIndex);
        }

        throw new UnsupportedOperationException("This table does not have identified column headers.");
    }
}
